You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
454 lines
12 KiB
454 lines
12 KiB
<script setup lang="ts">
|
|
definePageMeta({ layout: false });
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const { $toast } = useNuxtApp() as any;
|
|
|
|
const slugParam = computed(() => String(route.params.slug));
|
|
const isCreate = computed(() => slugParam.value === "new");
|
|
|
|
interface ModelOption {
|
|
id: number;
|
|
name: string;
|
|
modelId: string;
|
|
providerName?: string;
|
|
}
|
|
|
|
const models = ref<ModelOption[]>([]);
|
|
|
|
const form = reactive({
|
|
slug: "",
|
|
name: "",
|
|
avatar: "",
|
|
description: "",
|
|
personality: "",
|
|
scenario: "",
|
|
firstMessage: "",
|
|
exampleDialogue: "",
|
|
systemPrompt: "",
|
|
defaultModelId: null as number | null,
|
|
tagsText: "",
|
|
isPublic: 1,
|
|
enabled: 1,
|
|
sortOrder: 0,
|
|
});
|
|
|
|
const loading = ref(false);
|
|
const saving = ref(false);
|
|
|
|
async function loadModels() {
|
|
try {
|
|
const res = await $fetch<{ code: number; data: { list: any[] } }>("/api/llm/models");
|
|
const list = res.data?.list ?? [];
|
|
models.value = list.map((m) => ({
|
|
id: m.id,
|
|
name: m.name,
|
|
modelId: m.modelId,
|
|
providerName: m.providerName,
|
|
}));
|
|
} catch {
|
|
models.value = [];
|
|
}
|
|
}
|
|
|
|
async function loadCard() {
|
|
if (isCreate.value) return;
|
|
loading.value = true;
|
|
try {
|
|
const res = await $fetch<{ code: number; data: any }>(
|
|
`/api/admin/characters/${slugParam.value}`,
|
|
);
|
|
const d = res.data;
|
|
form.slug = d.slug ?? "";
|
|
form.name = d.name ?? "";
|
|
form.avatar = d.avatar ?? "";
|
|
form.description = d.description ?? "";
|
|
form.personality = d.personality ?? "";
|
|
form.scenario = d.scenario ?? "";
|
|
form.firstMessage = d.firstMessage ?? "";
|
|
form.exampleDialogue = d.exampleDialogue ?? "";
|
|
form.systemPrompt = d.systemPrompt ?? "";
|
|
form.defaultModelId = d.defaultModelId ?? null;
|
|
form.tagsText = Array.isArray(d.tags) ? d.tags.join(",") : "";
|
|
form.isPublic = d.isPublic ?? 1;
|
|
form.enabled = d.enabled ?? 1;
|
|
form.sortOrder = d.sortOrder ?? 0;
|
|
} catch (e: any) {
|
|
$toast?.error?.(e?.data?.message ?? "加载角色失败");
|
|
router.push("/admin/characters");
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!form.slug || !form.name) {
|
|
$toast?.error?.("slug、name 为必填");
|
|
return;
|
|
}
|
|
saving.value = true;
|
|
try {
|
|
const tags = form.tagsText
|
|
.split(",")
|
|
.map((t) => t.trim())
|
|
.filter(Boolean);
|
|
const body = {
|
|
slug: form.slug,
|
|
name: form.name,
|
|
avatar: form.avatar || null,
|
|
description: form.description || null,
|
|
personality: form.personality || null,
|
|
scenario: form.scenario || null,
|
|
firstMessage: form.firstMessage || null,
|
|
exampleDialogue: form.exampleDialogue || null,
|
|
systemPrompt: form.systemPrompt || "",
|
|
defaultModelId: form.defaultModelId,
|
|
tags: JSON.stringify(tags),
|
|
isPublic: form.isPublic,
|
|
enabled: form.enabled,
|
|
sortOrder: form.sortOrder,
|
|
};
|
|
if (isCreate.value) {
|
|
await $fetch("/api/characters", { method: "POST", body });
|
|
$toast?.success?.("创建成功");
|
|
} else {
|
|
await $fetch(`/api/characters/${slugParam.value}`, { method: "PUT", body });
|
|
$toast?.success?.("保存成功");
|
|
}
|
|
router.push("/admin/characters");
|
|
} catch (e: any) {
|
|
$toast?.error?.(e?.data?.message ?? "保存失败");
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadModels();
|
|
await loadCard();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="admin-character-edit">
|
|
<header class="page-header">
|
|
<NuxtLink to="/admin/characters" class="back-link">← 返回列表</NuxtLink>
|
|
<h1 class="page-title">{{ isCreate ? "创建角色卡" : "编辑角色卡" }}</h1>
|
|
</header>
|
|
|
|
<div v-if="loading" class="loading-state">加载中...</div>
|
|
|
|
<template v-else>
|
|
<!-- 基本信息 -->
|
|
<section class="config-section">
|
|
<h2 class="section-title">基本信息</h2>
|
|
<div class="form-grid">
|
|
<div class="form-field">
|
|
<label class="field-label">Slug <span class="required">*</span></label>
|
|
<input v-model="form.slug" type="text" class="config-input" placeholder="如 luna" :disabled="!isCreate" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label class="field-label">名称 <span class="required">*</span></label>
|
|
<input v-model="form.name" type="text" class="config-input" placeholder="角色名" />
|
|
</div>
|
|
<div class="form-field form-field-full">
|
|
<label class="field-label">头像 URL</label>
|
|
<input v-model="form.avatar" type="text" class="config-input" placeholder="https://... 或留空使用首字母" />
|
|
</div>
|
|
<div class="form-field form-field-full">
|
|
<label class="field-label">简介</label>
|
|
<input v-model="form.description" type="text" class="config-input" placeholder="一句话介绍这个角色" />
|
|
</div>
|
|
<div class="form-field form-field-full">
|
|
<label class="field-label">标签(逗号分隔)</label>
|
|
<input v-model="form.tagsText" type="text" class="config-input" placeholder="治愈, 陪伴, 夜谈" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 人设 -->
|
|
<section class="config-section">
|
|
<h2 class="section-title">人设(上下文自主设计)</h2>
|
|
<p class="section-desc">这些字段会在运行时组装成角色的 system prompt</p>
|
|
<div class="form-field">
|
|
<label class="field-label">性格</label>
|
|
<textarea v-model="form.personality" class="config-textarea" rows="3" placeholder="温柔、细腻、略带俏皮…" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label class="field-label">世界观 / 场景</label>
|
|
<textarea v-model="form.scenario" class="config-textarea" rows="4" placeholder="角色所处的背景设定…" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label class="field-label">开场白(进入会话时的首条消息)</label>
|
|
<textarea v-model="form.firstMessage" class="config-textarea" rows="3" placeholder="角色主动说出的第一句话…" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label class="field-label">对话示例(few-shot,约束口吻与风格)</label>
|
|
<textarea v-model="form.exampleDialogue" class="config-textarea" rows="5" placeholder="你:… 角色:…" />
|
|
</div>
|
|
<div class="form-field">
|
|
<label class="field-label">行为规范(底层约束)</label>
|
|
<textarea v-model="form.systemPrompt" class="config-textarea" rows="4" placeholder="始终以温柔治愈的语气回应…" />
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 模型与行为 -->
|
|
<section class="config-section">
|
|
<h2 class="section-title">模型与行为</h2>
|
|
<div class="form-field">
|
|
<label class="field-label">默认模型</label>
|
|
<select v-model="form.defaultModelId" class="config-select">
|
|
<option :value="null">— 未设置 —</option>
|
|
<option v-for="m in models" :key="m.id" :value="m.id">
|
|
{{ m.name }} ({{ m.modelId }}) — {{ m.providerName || "未知" }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-field" style="margin-top: 16px;">
|
|
<label class="field-label">排序</label>
|
|
<input v-model.number="form.sortOrder" type="number" class="config-input" style="width: 120px;" />
|
|
</div>
|
|
<div class="toggle-grid" style="margin-top: 16px;">
|
|
<label class="toggle-item">
|
|
<input type="checkbox" :checked="form.isPublic === 1" @change="form.isPublic = form.isPublic ? 0 : 1" />
|
|
<div class="toggle-info">
|
|
<div class="toggle-name">公开</div>
|
|
<div class="toggle-desc">是否在角色画廊中展示</div>
|
|
</div>
|
|
</label>
|
|
<label class="toggle-item">
|
|
<input type="checkbox" :checked="form.enabled === 1" @change="form.enabled = form.enabled ? 0 : 1" />
|
|
<div class="toggle-info">
|
|
<div class="toggle-name">启用</div>
|
|
<div class="toggle-desc">角色是否可用</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="action-bar">
|
|
<NuxtLink to="/admin/characters" class="btn-cancel">取消</NuxtLink>
|
|
<button class="save-btn" :disabled="saving" @click="handleSave">
|
|
{{ saving ? "保存中..." : isCreate ? "创建" : "保存" }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.admin-character-edit {
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
padding: 32px 24px;
|
|
}
|
|
|
|
.page-header {
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.back-link {
|
|
display: inline-block;
|
|
font-size: 13px;
|
|
color: var(--color-muted);
|
|
text-decoration: none;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.back-link:hover {
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.page-title {
|
|
font-family: var(--font-display);
|
|
font-size: 28px;
|
|
font-weight: 400;
|
|
color: var(--color-ink);
|
|
margin: 0;
|
|
}
|
|
|
|
.loading-state {
|
|
text-align: center;
|
|
padding: 48px;
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.config-section {
|
|
margin-bottom: 32px;
|
|
padding: 24px;
|
|
background: var(--color-surface-card);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 16px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: var(--color-ink);
|
|
margin: 0 0 4px;
|
|
}
|
|
|
|
.section-desc {
|
|
font-size: 13px;
|
|
color: var(--color-muted);
|
|
margin: 0 0 16px;
|
|
}
|
|
|
|
.required {
|
|
color: #e53e3e;
|
|
}
|
|
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 16px;
|
|
}
|
|
|
|
.form-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.form-field-full {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.field-label {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.config-input {
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
color: var(--color-ink);
|
|
background: var(--color-canvas);
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
|
|
.config-input:focus {
|
|
border-color: var(--color-primary);
|
|
}
|
|
|
|
.config-input:disabled {
|
|
background: var(--color-surface-soft);
|
|
color: var(--color-muted);
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.config-textarea {
|
|
width: 100%;
|
|
padding: 12px 14px;
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 10px;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
color: var(--color-ink);
|
|
background: var(--color-canvas);
|
|
resize: vertical;
|
|
outline: none;
|
|
}
|
|
|
|
.config-textarea:focus {
|
|
border-color: var(--color-primary);
|
|
}
|
|
|
|
.config-select {
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
color: var(--color-ink);
|
|
background: var(--color-canvas);
|
|
outline: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.toggle-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 8px;
|
|
}
|
|
|
|
.toggle-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
padding: 12px;
|
|
background: var(--color-surface-soft);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.toggle-item input[type="checkbox"] {
|
|
margin-top: 2px;
|
|
accent-color: var(--color-primary);
|
|
}
|
|
|
|
.toggle-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.toggle-name {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.toggle-desc {
|
|
font-size: 12px;
|
|
color: var(--color-muted);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.action-bar {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 12px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.btn-cancel {
|
|
padding: 8px 20px;
|
|
background: var(--color-surface-card);
|
|
color: var(--color-body-strong);
|
|
border: 1px solid var(--color-hairline);
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.btn-cancel:hover {
|
|
background: var(--color-surface-soft);
|
|
}
|
|
|
|
.save-btn {
|
|
padding: 8px 24px;
|
|
background: var(--color-primary);
|
|
color: var(--color-on-primary);
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.save-btn:hover:not(:disabled) {
|
|
background: var(--color-primary-active);
|
|
}
|
|
|
|
.save-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
</style>
|
|
|